Skip to content

Content: R2 + R3, and the title truncation bug behind 454 broken SERP entries - #520

Merged
pftg merged 8 commits into
masterfrom
content/r2-r3-plus-ctr-upgrades
Aug 20, 2026
Merged

Content: R2 + R3, and the title truncation bug behind 454 broken SERP entries#520
pftg merged 8 commits into
masterfrom
content/r2-r3-plus-ctr-upgrades

Conversation

@pftg

@pftg pftg commented Aug 20, 2026

Copy link
Copy Markdown
Member

Two new tech posts (R2, R3) plus the SEO cleanup Paul asked for. Five commits.

The SEO work turned out to be the bigger half

Paul asked me to fix two page-1 posts taking almost no clicks. Reading the rendered output instead of the source found a template bug affecting three quarters of the blog.

enhanced-meta-tags.html truncated every post title twice. It cut the title to 45, appended " | JetThoughts Blog" (19 chars) for 64, then cut the result again at 60. 45 + 19 = 64 > 60 always, so any title of 42+ characters reached the SERP trailing an ellipsis after the brand:

before: How we configure Simplecov for our Ruby on | JetThoughts…
after:  How to Configure SimpleCov in Rails

The template's two constants could never both hold - its real safe length was 41, not the 45 the code implied. That is also why the blog pipeline's "title <= 45" rule exists, which I had been following without knowing the mechanism.

454 of 614 posts were affected. Of 690 rendered titles now, exactly one contains an ellipsis - Why AI Hasn't Blown Our Minds…Yet, where it is deliberate punctuation. Fix is structural rather than numeric: truncate once, and move the length guard into the branch that still appends a suffix.

Separately, 234 posts served a description ending in a bare ... - dev.to truncates near 100 chars and the importer copied the ellipsis into our meta tags. 141 rebuilt from each post's own opening prose; 92 left for a human.

That number is 141 and not 220 because I inspected the output before shipping it. My first pass fell back to a word-boundary cut and about a quarter came out dangling ("...the results you envisioned for your"), which is worse than the ellipsis it replaced - nothing signals to the reader that the text was cut. Reverted all 220, rewrote the rule as sentence-boundary-or-skip, re-ran. Verified across all 141: zero end in ..., zero end without terminal punctuation.

R2 - RubyLLM vs Langchainrb for Rails

Researched by unpacking ruby_llm 1.16.0, langchainrb 0.19.5 and langchainrb_rails 0.1.12. The thesis came out of the file lists: one gem ships chat/agent/embedding/cost, the other ships vectorsearch/chunker/loader/evals. They are not competing implementations of one job, so the decision is "do you retrieve from your own documents". That also satisfies the plan's constraint that R2 must LINK the LangChain guides rather than cannibalise them.

Three critics plus a cold-eyes gate. What they caught:

  • The provider count was wrong. I wrote 10 for ruby_llm; it registers 13. My ls | head -20 truncated the listing and I read the cut-off result as complete. The correct number is better for the post - 13 vs 13 is a tie, so the row settles nothing, which is the argument. The old draft warned against choosing on provider count while printing a wrong one.
  • I dismissed a gem I had not opened. The draft waved at langchainrb_rails 0.1.12 with "that version tells you what to expect". Opening it proved me wrong: four generators, an ActiveRecord hook, a Railtie. The truth argues the thesis better - ruby_llm's generators scaffold a conversation, langchainrb_rails' scaffold a vector store.
  • The stability row was a cheap shot. ruby_llm is past 1.0 but ships four upgrade generators and an acts_as_legacy shim - four schema migrations inside minor releases. Now symmetric.
  • The missing cover failed silently to the site default og-default.jpg with no build error. Caught by checking rendered og:image, not the file listing.

The code sample was verified by a reviewer running it - Chat.create! persists and chat.ask reaches the provider, failing only on a dummy key.

R3 - What RubyLLM Retries, and For How Long (rescoped)

The queue row read "rate limits, token budgets, retries, streaming into Turbo". Three of those four were already owned by posts that shipped after the row was groomed - token budgets by cost-optimization-llm-applications-token-management, and both streaming and rate limiting by fibers-async-ruby-llm-streaming-rails, which carries a "Rate limiting the upstream calls" section. Writing it as specified would have cannibalised two posts. Only retries were unclaimed. Verdict recorded in 20.09 and the row retired.

I was wrong twice before reading the gems, which is why the post is worth having:

  1. Guessed ruby_llm's 0.1s interval was too fast for a rate limit. Wrong - faraday-retry reads Retry-After and the rate-limit reset header and takes the larger.
  2. Then assumed a sane ceiling on that wait. Wrong - max_interval defaults to Float::MAX and ruby_llm never sets it, so the guard that would abandon an over-long wait never fires.

Cold-eyes caught the worst defect: I had cited our own pool-exhaustion post as evidence for what happens when a model call is not in a job. That incident happened inside a job. Now reads "a job is not a free pass either", which also resolves a contradiction three paragraphs earlier.

The fact-checker also found request_timeout is exposed (300s default), so my "you cannot configure your way out" was misleading - and the real worst case stacks four attempts x 300s against three Retry-After waits, well over half an hour rather than five minutes.

What I refused

Three critic rewrites proposed inventing evidence to satisfy the "named actor, specific count" voice rule - a client bill going "$340 to $1,900", a count of "two of the last four apps", an OpenAI format change "found in production". None happened. I used the one real published incident instead, then checked my own citation and caught myself inflating "nine schemas in one pipeline" into "nine agent pipelines".

Gates

  • bin/hugo-build 8/8 validators on every commit
  • check-post-visuals at floor 72 - both posts carry a decision diagram that routes rather than restating an adjacent table
  • Mermaid pre-rendered: R2 499.9px viewBox = 9.36px at 390px; R3 257px = true-size. Both clear the 9px floor
  • Rendered scroll gate at 390px on both: zero console errors, no page overflow, tables fit at 464
  • qtest --changed on the template commit: no visual-affecting changes, correctly - meta tags move no pixels
  • Cluster sweep: no shared phrasing, no duplicated headings, different opener shapes

Known, not fixed

  • Wide tables have no scroll container anywhere on the site. R2's four-column table rendered 520px inside a 464px container at 390px with the last column clipped and unreachable. Dropped that column here, but the theme-level fix needs its own change plus a visual regression run.
  • 92 posts still have a truncated description - those whose opening prose has no complete sentence inside the budget. They need writing, not scripting.
  • Two critics rather than four on R3 (fact-checker + cold-eyes), since those two caught every shipping defect on R2 while the style critics caught style.

Review links:

🤖 Generated with Claude Code

pftg added 5 commits August 20, 2026 23:58
Two posts rank page-one and take almost no clicks: `solid queue vs
sidekiq` at position 5.2 with 0.86% CTR (116 impressions, 1 click), and
`simplecov` at 10.3 with 0.87% (115 impressions, 1 click). Expected CTR
at position 5 is roughly 6%.

I assumed by analogy to the Kamal post that this was a weak title, then
read the RENDERED output instead of the source, and the real defect was
mechanical.

`layouts/partials/seo/enhanced-meta-tags.html` truncates a blog title to
45 chars (:14), appends " | JetThoughts Blog", then truncates the whole
string at 60 with an ellipsis (:8, :22). A title over 45 therefore gets
cut twice and lands in the SERP trailing an ellipsis AFTER the brand:

  before: Solid Queue vs Sidekiq: Complete Comparison | JetThoughts…
  after:  Solid Queue vs Sidekiq: When Each Wins | JetThoughts Blog

  before: How we configure Simplecov for our Ruby on | JetThoughts…
  after:  How to Configure SimpleCov in Rails | JetThoughts Blog

That is also WHY the pipeline's "title <= 45 chars" rule exists - a rule
I had been following without knowing the mechanism.

Descriptions were broken independently. Solid Queue's ran 162 chars and
lost "included." to the 160 cap. SimpleCov's ended in a literal "..." in
the source file - a dev.to import artifact, since dev.to truncates
descriptions near 100 chars and the importer carried the ellipsis into
our meta tag verbatim.

Both descriptions now answer the query instead of listing the contents,
and both render whole.

Content untouched. Solid Queue is 2,161 words and did not need it;
SimpleCov is 561 words and DOES look thin against SimpleCov's own docs
at position 10 - flagged, not fixed here, because that is a rewrite
rather than a snippet fix.

Also in this commit: the 2026-08-20 P0 gate override recorded in 20.09,
so a later session reads the content sprint as a deliberate call rather
than a gate nobody checked.

Verified in rendered HTML, not source: bin/hugo-build 8/8 validators,
zero ellipsis in either title or description.
…llipsis

Paul authorised dropping the brand suffix if it helped. It does, and the
underlying bug was arithmetic the template contradicted itself on.

`enhanced-meta-tags.html` cut a post title to 45, appended
" | JetThoughts Blog" (19 chars) for 64, then cut the result again at 60.
45 + 19 = 64 > 60 ALWAYS, so any title of 42+ characters was truncated
twice and reached the SERP trailing an ellipsis after the brand name:

  How we configure Simplecov for our Ruby on | JetThoughts…
  Solid Queue vs Sidekiq: Complete Comparison | JetThoughts…
  Insights from Zapier's CTO on Managing Remote…

The template's own two constants could never both hold. Its effective
safe length was 41 chars, not the 45 the code implied - which is also
why the blog pipeline's "title <= 45" rule exists, a rule I had been
following without knowing the mechanism.

Measured before the change: 454 of 614 posts (74%) carried titles over
45 chars. After: of 690 rendered post titles, exactly ONE still contains
an ellipsis - "Why AI Hasn't Blown Our Minds…Yet", where it is deliberate
punctuation.

Fix is structural rather than numeric: truncate EXACTLY once. Post titles
take the full 60 with no brand suffix; the second length guard moved
inside the non-blog branch, where the suffix is still appended and still
needs it. Google appends the site name itself when it wants one.

Diagnosis note, because the wrong answer was convincing: reading the
source suggested the blog branch already handled this. Rendered output
disagreed. Two probe markers in the template proved the second truncate
was firing on top of the first - the branch was right and ran twice.
Source-reading would have shipped a no-op.

Gates: bin/hugo-build 8/8 validators. `bin/qtest --changed` reports no
visual-affecting changes, correctly - title and meta tags move no pixels.
og:title and twitter:title verified to follow the same clean string.
…ntence

234 of 615 posts served Google a description ending in a bare '...'.
The cause is structural: 529 posts (86% of the blog) came from dev.to,
which truncates its description near 100 chars, and the importer copied
that ellipsis into our frontmatter verbatim.

The full sentence is almost always still in the post body, so these are
rebuilt from the body's first real prose - headings, code fences, images
and list markers skipped.

STRICT on purpose: a description is only written when whole sentences
fit the 158-char budget. My first attempt fell back to a word-boundary
cut and I inspected the output before shipping it - roughly a quarter
ended in dangling phrases:

  "...and create not just a good headline, but a catchy one? No matter
   what your content type is, and if you're either writing a small"
  "...to get the results you envisioned for your"

That is worse than the ellipsis it replaced, because nothing signals to
the reader that the text was cut. So the rule is now sentence-boundary
or skip.

Cost of the stricter rule: 141 rebuilt instead of 220, and 92 left for a
human rather than auto-filled badly. Verified mechanically across all
141 written: zero end in '...', zero end without terminal punctuation.

Remaining 92 need a written description - they are posts whose opening
prose has no complete sentence inside the budget. Not attempted here.

bin/hugo-build: 8/8 validators.
Queue row R2. Researched by unpacking both gems, not from memory:
ruby_llm 1.16.0, langchainrb 0.19.5, langchainrb_rails 0.1.12.

The thesis came out of the file lists rather than a feature survey. One
gem ships chat/agent/embedding/cost/model-registry; the other ships
vectorsearch/chunker/loader/output_parsers/evals. They are not competing
implementations of one job, so the decision is "do you retrieve from your
own documents", not "which is better". That also satisfies the plan's
constraint that R2 must LINK the LangChain guides rather than cannibalize
them.

Three critics plus a cold-eyes gate ran. What they caught:

- PROVIDER COUNT WAS WRONG. I wrote 10 for ruby_llm; it registers 13
  (lib/ruby_llm.rb:104-116). My `ls providers/ | head -20` truncated the
  listing and I read the cut-off result as complete - the same error as
  an undersized grep window earlier in the session. The correct number
  is better for the post: 13 vs 13 is a tie, so the row settles nothing,
  which is the point. The old draft warned against choosing on provider
  count while printing a wrong one.

- THE STABILITY ROW WAS A CHEAP SHOT. "past 1.0 vs pre-1.0" implied
  quiet upgrades. ruby_llm ships upgrade_to_v1_7/v1_9/v1_10/v1_14
  generators and an acts_as_legacy shim - four schema migrations inside
  minor releases. Now symmetric: budget for migrations on either.

- I DISMISSED A GEM I HAD NOT OPENED. The draft waved at
  langchainrb_rails 0.1.12 with "that version tells you what to expect".
  Opening it proved me wrong: four generators (pgvector, pinecone,
  chroma, prompt), an ActiveRecord hook, a Railtie. The truth argues the
  thesis better than the smirk did - ruby_llm's generators scaffold a
  conversation, langchainrb_rails' scaffold a vector store.

- COST CLAIM OVERSTATED. langchainrb does ship prompt/completion/total
  token counts; what it lacks is price and the cache/thinking split.
  "You supply the price table" replaces "you fly blind".

- FIVE BANNED DEFINITIONAL CONSTRUCTIONS, one slogany flip, one negative
  parallelism, one "teams add last and wish they had added first"
  chiasmus. All removed; sweep now returns zero.

Rejected three critic rewrites that would have fabricated evidence - an
invented client bill going "$340 to $1,900", a count of "two of the last
four apps", an OpenAI format change "found in production". None
happened. Used the one real published incident instead (nine schemas,
1549 green tests, VCR matching on method and URI only) and checked my
citation against the source post, which caught me inflating it to "nine
agent pipelines" when it was nine schemas in one pipeline.

Model id in the sample is claude-sonnet-4-5, not gpt-4o. gpt-4o still
resolves in the registry, but the gem's own README uses a current model
and this post argues that models get retired underneath you.

Gates: bin/hugo-build 8/8. check-post-visuals back at floor 72 (the
decision tree, which routes rather than restating the table). Mermaid
pre-rendered, 499.9px viewBox = 9.36px at 390px against a 9px floor.
Rendered scroll gate at 390px: zero console errors, no page overflow.

Cover generated and verified in rendered output - cold-eyes caught that
the missing cover.png was falling back SILENTLY to the site default
og-default.jpg with no build error. og:image now resolves to the real
file.

Dropped the Raw HTTP column from the table: at 390px it rendered 520px
wide in a 464px container with overflow-x visible, so the whole column
was clipped and unreachable. Its cells all read "you write it" and it
has its own section. Table now fits exactly at 464.

Noted, not fixed: that clipping is a theme-level issue, not specific to
this post - wide tables have no scroll container. Needs its own change
plus a visual regression run.
The queue row R3 read "rate limits, token budgets, retries, streaming
into Turbo". Audited against content/blog/ before drafting and three of
those four were already owned by posts that shipped AFTER the row was
groomed:

  token budgets  -> cost-optimization-llm-applications-token-management
  streaming      -> fibers-async-ruby-llm-streaming-rails
  rate limits    -> same post, "Rate limiting the upstream calls"

Writing it as specified would have cannibalised two posts - the same
collision that killed R9 and redirected the Kamal work. Only retries
were unclaimed, so the post is retry semantics. Verdict recorded in
20.09 and the row retired.

Researched by unpacking ruby_llm 1.16.0 and faraday-retry 2.4.0. I was
wrong twice before reading them, which is the reason the post exists:

1. Guessed the 0.1s interval was too fast for a rate limit. Wrong -
   faraday-retry reads Retry-After AND the rate-limit reset header and
   takes the larger, so a 429 gets the provider's number.
2. Then assumed a sane ceiling on that wait. Wrong - max_interval
   defaults to Float::MAX (middleware.rb:55) and ruby_llm never sets it,
   so the guard that would abandon an over-long wait never fires.

The other half is connection.rb:111 adding :post back to Faraday's
IDEMPOTENT_METHODS, which excludes POST by design. Correct for chat
completions, dangerous for a tool call with a side effect.

Fact-checker verified all eight core claims against source and caught:

- retries vs attempts off-by-one (max: 3 is three RETRIES, four attempts)
- request_timeout IS exposed and defaults to 300s. My draft said you
  cannot configure your way out; the real worst case stacks four
  attempts x 300s against up to three Retry-After waits, so well over
  half an hour rather than the five minutes one Retry-After suggests
- "would never retry anything at all" was false - a GET for the model
  list still retries. Narrowed to "a single completion"
- Faraday::RetriableResponse is listed but inert, because retry_statuses
  is never set. Source-true, behaviour-false - now labelled dormant
- the OpenAI source link implied provider header behaviour I had not
  verified. Replaced with a note telling the reader to check their own
  provider's docs

Cold-eyes caught the worst one: I cited our own pool-exhaustion post as
evidence for what happens when a model call is NOT in a job. That
incident happened INSIDE a job. It now reads "a job is not a free pass
either", which also resolves a contradiction with "inside a Sidekiq job
that is fine" three paragraphs earlier. It also pulled the model-
retirement claim back to what the source post actually supports,
matching the correction shipped in #509.

Gates: bin/hugo-build 8/8. check-post-visuals at floor 72 (decision
diagram, 257px viewBox - 12px text renders true-size at 390px, clear of
the 9px floor). Rendered scroll gate at 390px: zero console errors, no
page overflow, table fits at 464. og:image resolves to the post's own
cover derivative, verified in built HTML rather than assumed.

Ran two critics rather than four - the fact-checker and cold-eyes, which
between them caught every shipping defect on R2 while the style critics
caught style. Stating the reduction rather than implying full coverage.
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: de1095a4-054f-4552-9e14-efe9a06cf4d8


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

pftg added 3 commits August 21, 2026 01:22
…rce lists

Paul asked for plainer English and less AI-feel. The voice guide's first
gate is exactly this - a sentence the reader has to decode has already
failed, no matter how it scores on the mechanical checks.

The sentences that needed it were the ones carrying a metaphor where a
plain word would do, or two ideas welded together:

  "Both gems brought their own centre of gravity into Rails"
    -> "Each gem brought the thing it is good at into Rails"
  "Providers are a tie at thirteen each, so that row settles nothing"
    -> "Both ship thirteen providers, so that row will not help you choose"
  "Neither gem promises you a quiet upgrade"
    -> "Neither one gives you upgrades for free"
  "That failure is quieter than it sounds"
    -> "That kind of change is easy to miss"
  "A second gem constructing its own requests doubles the surface"
    -> "twice as many places for that to hide"
  "What you keep writing yourself is the boring layer"
    -> "What you write yourself is the dull but necessary part"
  "Source-true, behaviour-inert"
    -> "It is in the code, but nothing reaches it"

Also removed both Sources blocks. Paul flagged them as redundant and the
slop critic had said the same thing earlier - six links, every one a
first-party vendor page the reader can find from the gem name. A trailing
bibliography reads as generated; thoughtbot links where the claim lives.
Each post now ends with one line naming the two things actually read and
telling the reader to check their own installed versions.

Facts unchanged: 13/13 providers, the 1549-test incident, Float::MAX, the
0.1/0.2/0.4 schedule, every version number. bin/hugo-build 8/8.

Tension worth noting for later: #510 standardised 20 posts onto a
"## Sources" heading. That was about naming lists consistently, not about
whether they should exist. This commit is the other half - do not pad one
with generic vendor links just because the heading is there.
Section 0 had one worked example, from a first-person LinkedIn post,
where the defects were borrowed drama and inverted causality. Today's
pass on two tech posts hit a different family, and it is the one that
recurs in the Rails stream: an abstraction standing where a plain word
fits.

Added the seven-row table of what shipped vs what it became - 'centre of
gravity', 'settles nothing', 'quiet upgrade', 'doubles the surface',
'Source-true, behaviour-inert'. Every one of those passed banned-word,
em-dash and slop checks. They fail the only test that matters: the
reader has to translate before they can use the sentence.

Named the tell so it is greppable in review: a noun phrase doing a
verb's job. 'Centre of gravity', 'the surface', 'the boring layer' all
gesture at a shape instead of saying what happens. The fix is the
concrete verb, after which the metaphor is unnecessary.

Also added a 'padded source list' row to the structural-patterns table.
Six first-party vendor links shipped on a draft today; Paul flagged them
as redundant and a slop critic had called the shape a generated-text
tell earlier in the same session. Worth recording because #510 had just
standardised 20 posts onto a '## Sources' heading - making a section
cheap to add is what makes padding it easy, so the naming convention and
this rule have to travel together.
…utes

A Codex review found a P1 that made the whole SEO effort a no-op, plus a
bug in my own generator. Both verified in source before fixing.

1. THE REPAIRS WERE NOT DURABLE. lib/sync/post.rb:39-45 re-pulls title
   and description from dev.to unless the frontmatter carries
   `seo_override: true`. Its own comment says "the 10-min sync cron
   clobbers any locally-edited SEO snippet". None of the 141 files I
   edited had the flag, so every rebuilt description and the hand-written
   SimpleCov title would have been reverted within ten minutes of merge -
   silently, with the commits still in history looking like they worked.

   All 140 dev.to-backed files now set it, verified by script: 140 with
   the flag, 0 missing. The SimpleCov post gets it too, since its title
   and description were both hand-written.

2. MY GENERATOR ATE UNDERSCORES. clean() ran gsub(/[*_`]/, "") to strip
   markdown emphasis, which also stripped underscores inside identifiers.
   `stringify_keys` shipped as "stringifykeys" - destroying the exact
   keyword that post targets, in the description Google reads.

   Emphasis is now stripped only when the markers actually wrap
   something, and inline code is unwrapped rather than deleted. Blast
   radius measured rather than assumed: exactly 1 of 141 descriptions
   contained an underscore, so one post was mangled, not many. All 141
   were restored to their pre-commit state and regenerated from source
   with the corrected cleaner - 140 rebuilt, 93 left for a human.

Two factual corrections in the new posts, both verified in the gems:

- R2 claimed langchainrb gives token counts "on every response". The
  Hugging Face, llama.cpp and Replicate response classes never override
  those methods, so they inherit base_response and raise
  NotImplementedError. Now scoped to the mainstream adapters, with the
  exceptions named.

- R3 said a retried POST could duplicate a row write or an email. It
  cannot. chat.rb:233 runs handle_tool_calls only after a response
  returns, and the retry middleware wraps the HTTP request below that -
  so a lost response can double-charge the completion, but the first
  attempt never reached the tool. Duplicate side effects come from
  retrying the job around the call. The post now says so.

Local macOS suite (not CI): 34 runs, 87 assertions, 0 failures, and 53
screenshots compared with no failures. That also answers the CI
Screenshot Tests failure - footer and CTA regions on homepage/services
do not reproduce locally, and the linux baselines date from #494 with
design work landed since (#503 tokens, #508 palette). Drift, not this
diff.

bin/hugo-build 8/8.
@pftg
pftg merged commit ca97ff0 into master Aug 20, 2026
1 of 2 checks passed
@pftg
pftg deleted the content/r2-r3-plus-ctr-upgrades branch August 20, 2026 23:32
pftg added a commit that referenced this pull request Aug 20, 2026
#521)

* fix(content): R2's diagram was below the 9px floor, and "plain HTTP" is not a term

Two corrections, both mine.

1. THE FLOOR MEASUREMENT WAS WRONG ALL SESSION. The formula is
   minFontSize * (displayedWidth / viewBoxWidth). I used the VIEWPORT
   width (390) as displayedWidth. The actual content column at a 390px
   viewport is 354px. R2's diagram measured 9.36px by my arithmetic and
   8.5px in reality - below the floor, and shipped.

   Worse, chrome-devtools resize_page(390) does not give a 390px CSS
   viewport; it clamps at 500. Every "390px mobile" check I reported
   this session was actually taken at 500. `emulate` with
   390x844x3,mobile,touch gives a true 390.

   Narrowing labels did not fix it - the viewBox went UP, 478 then 488 -
   because mermaid width is driven by parallel columns, not label
   length, exactly as the pipeline warns. A branching decision tree
   cannot clear this floor. Rebuilt as a vertical ladder: 272px viewBox,
   12px rendered, verified in the browser at a true 390 viewport.

   R3 (257px) and the Kamal post (272px) were re-checked against the
   354px column and both pass.

2. "plain HTTP" is not a term. Paul flagged it: "raw HTTP" is what
   developers call this. I introduced it during the plain-English pass,
   which was the wrong move - plain English means cutting abstractions
   the reader must decode, not swapping established terminology for
   words I invented. Reverted everywhere in this post.

RETRACTION, recorded because I reported it to Paul as fact: there is NO
theme-level table overflow bug. single-post.css:287-296 already sets
display:block + overflow-x:auto on post tables under 767px, and it
works - a true 390px check on fractional-cto-vs-full-time-cto shows both
tables scrolling inside themselves (406 and 420 in a 354 container) with
no page overflow. My original diagnosis read
getComputedStyle(parentElement) - the wrapper DIV - when the rule
targets the table itself. Dropping R2's Raw HTTP column was therefore
not required; it stays dropped on content grounds, since every cell read
"you write it".

bin/hugo-build 8/8.

* fix(seo): recover 76 of the 92 descriptions the first pass left for a human

Diagnosed before fixing, because the 92 were not one problem:

  63  first sentence complete, but under my 90-char minimum (83, 69 chars)
  26  first sentence longer than 158 (159, 209 chars)
   4  first prose line has no sentence ending at all

The 63 were an artificial floor I had invented. A complete 83-character
sentence is a perfectly good meta description - what has to be avoided is
a FRAGMENT, not a short sentence, and I had conflated the two. MIN drops
to 55.

For the 26, the fix is to walk further into the body for a sentence that
fits rather than cutting the long one at a clause boundary. Clause cutting
is exactly what produced the dangling text in the first attempt.

Result: 76 rebuilt, 17 still skipped, lengths 59-156. Verified across all
76 written: zero end in "...", zero end without terminal punctuation, and
all 76 dev.to-backed files carry seo_override so the 10-minute sync cron
cannot revert them.

Site-wide, truncated descriptions go 234 -> 17. The remaining 17 are posts
whose opening prose genuinely offers no usable sentence - announcement
posts and link roundups, mostly. Those need writing, and a script should
not guess at them.

bin/hugo-build 8/8.
pftg added a commit that referenced this pull request Aug 21, 2026
The re-record was dispatched to CI on this branch (run 32526977795, native
amd64 - the only trusted way to record linux/ baselines; local ARM Docker
plants false drift) and succeeded. FORCE_SCREENSHOT_UPDATE re-records
EVERYTHING, so the bot commit touched 83 files and needed screening.

Screened with the gate's OWN instrument rather than by eye or by file size:
capybara-screenshot-diff counts a pixel as differing only above
perceptual_threshold = 2.0 dE00, and the default tolerance is now 0.0001 of
the frame. Computing that same libvips dE00 fraction for every file in the bot
commit splits it cleanly:

  keep  77  would FAIL the gate, so the rewrite is real
  drop   6  difference_level exactly 0.00000000 - would have PASSED

The 6 dropped are pure encoder churn with zero perceptual difference:
desktop+mobile course/landing, desktop+mobile blog/special/codeblocks/text,
clients/agent-inbox/_overview, clients/single-full.

Of the 77 kept, 8 are NEW keys with no linux baseline at all - friday_report
(desktop+mobile) and the three register pilots next/{editorial,precision,
rescue-room} (desktop+mobile) - and the rest span 0.000139 to 0.973.

Why the Linux set is broader than the macOS one (77 vs 50), which looks wrong
until you check it: earlier PRs re-recorded only the macOS side. #528 committed
macos/mobile/services.png and left its linux/ twin stale, so linux/mobile/services
carries a visible vertical shift that macOS no longer has - confirmed by
painting the dE00 mask over the capture: identical content, offset position.
The reverse also holds - linux/desktop/blog/special/inline_style_post barely
moves (0.000139) because the Linux blog baselines were already re-recorded
after #520, while macOS was 25% stale. Neither OS is a subset of the other.

Two kept files were inspected directly rather than accepted on their number:
linux/desktop/404.png (0.001235) is a 4px-wide strip at the right edge - a
scrollbar artifact, page content identical, and 4 x 640 px is exactly the
reported difference_level; linux/mobile/services.png (0.1389) is the #528
shift above. Both render correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SP5gaqXEgUie8pdFrmbeJ
pftg added a commit that referenced this pull request Aug 21, 2026
…pting

The macOS visual suite has been red on master since #540. `bin/rake
test:system` at clean HEAD: 79 runs, 26 failures, 134 screenshots compared,
48 failures. All 48 are correctly-detected staleness, not regressions -
nothing here was verified by difference_level alone.

Why they went stale: a green run restores the git-HEAD image over the fresh
capture, so any change small enough to pass fossilizes the baseline. #540
was large enough to fail and simply was not re-recorded; the rest had been
accumulating under the old 0.02 default since #520 and #528.

Classification (baseline-vs-candidate pixel transitions, not diff levels):

  42  #540 dark-surface recolour. rgb(0,0,0) -> rgb(20,17,15) accounts for
      94-100% of every changed pixel in each file (--surface-ink #14110f).
      Footers, CTA bands, contact_us, both pagination frames, codeblock
      frames - the same surfaces #540's own commit message lists as failing.

   3  Post `description` backfilled by #520, which the post template renders
      as a grey lead above the body, pushing content down. Verified the
      rendered text is the frontmatter string verbatim, e.g.
      stimulus-keyboard-event-filter: "In the last update of Stimulus
      v3.2.2, the ability to filter the event by PageDown and PageUp keys
      was added." (desktop+mobile code_highlight_post, inline_style_post)

   2  Course module eyebrow added by #552 - "MODULE 1 - HYPOTHESIS & SMOKE
      TEST" now renders in ruby above the H1, shifting the page down.
      (desktop+mobile course/chapter)

   1  services/_testimonials-header: a 1px vertical offset from #528's
      eyebrow consolidation. #528 touched services-critical.css and
      content-block.css and re-recorded only the two sections that exceeded
      0.02; the sections below the eyebrow shifted 1px, passed under the old
      default, and fossilized. Measured, not inferred: realigning the
      baseline by dy=+1 drops the residual to exactly 0.0000, i.e. the
      content is pixel-identical and only its position moved.

Evidence that these are stale baselines rather than flaky rendering: two
consecutive runs of DesktopSiteTest#test_services reported difference_levels
identical to 17 decimal places (_technologies 0.013838252314814815,
_use-cases 0.01749807098765432, _testimonials-header 0.005922550154320988),
and a second full-suite run produced the same 48-file set.

One instrument error worth recording, since it nearly became a finding: the
first transition script loaded each baseline from a reused temp filename, and
libvips caches operations by filename - so runs 2..n silently re-compared
image 1. It reported services/_footer's baseline as a pure-black frame. A
brightness audit over all 155 tracked macOS baselines refuted it (darkest
mean is 69/255, none below 1.0), and reloading from git buffers fixed it. The
control that keeps the rewritten script honest: an unchanged baseline
(desktop/vibe_code_rescue) reports exactly 0 changed pixels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SP5gaqXEgUie8pdFrmbeJ
pftg added a commit that referenced this pull request Aug 21, 2026
The re-record was dispatched to CI on this branch (run 32526977795, native
amd64 - the only trusted way to record linux/ baselines; local ARM Docker
plants false drift) and succeeded. FORCE_SCREENSHOT_UPDATE re-records
EVERYTHING, so the bot commit touched 83 files and needed screening.

Screened with the gate's OWN instrument rather than by eye or by file size:
capybara-screenshot-diff counts a pixel as differing only above
perceptual_threshold = 2.0 dE00, and the default tolerance is now 0.0001 of
the frame. Computing that same libvips dE00 fraction for every file in the bot
commit splits it cleanly:

  keep  77  would FAIL the gate, so the rewrite is real
  drop   6  difference_level exactly 0.00000000 - would have PASSED

The 6 dropped are pure encoder churn with zero perceptual difference:
desktop+mobile course/landing, desktop+mobile blog/special/codeblocks/text,
clients/agent-inbox/_overview, clients/single-full.

Of the 77 kept, 8 are NEW keys with no linux baseline at all - friday_report
(desktop+mobile) and the three register pilots next/{editorial,precision,
rescue-room} (desktop+mobile) - and the rest span 0.000139 to 0.973.

Why the Linux set is broader than the macOS one (77 vs 50), which looks wrong
until you check it: earlier PRs re-recorded only the macOS side. #528 committed
macos/mobile/services.png and left its linux/ twin stale, so linux/mobile/services
carries a visible vertical shift that macOS no longer has - confirmed by
painting the dE00 mask over the capture: identical content, offset position.
The reverse also holds - linux/desktop/blog/special/inline_style_post barely
moves (0.000139) because the Linux blog baselines were already re-recorded
after #520, while macOS was 25% stale. Neither OS is a subset of the other.

Two kept files were inspected directly rather than accepted on their number:
linux/desktop/404.png (0.001235) is a 4px-wide strip at the right edge - a
scrollbar artifact, page content identical, and 4 x 640 px is exactly the
reported difference_level; linux/mobile/services.png (0.1389) is the #528
shift above. Both render correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SP5gaqXEgUie8pdFrmbeJ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant